import { posix as posixPath } from 'path'
import Path from 'path'
import { Route, ResolvedOptions } from './types'
import { intersection, merge, debounce, once, mergeWith } from 'lodash-es'
import { watch, FSWatcher } from 'chokidar'
import {
  extensionsToGlob,
  normalizePath,
  parseConstance,
  UnkownValue,
  debug,
  hashString,
} from './utils'
import { EventEmitter } from 'events'
import fs from 'fs-extra'
import JSON5 from 'json5'
import YAML from 'yaml'
import { parse as vueSfcParse } from '@vue/compiler-sfc'
import { parse as babelParse, ParserOptions as BabelParserOptions } from '@babel/parser'
import traverse from '@babel/traverse'
import deepEqual from 'deep-equal'

abstract class PageBase {
  private _name: string
  _parent: PageDir | undefined
  constructor(name: string, parent?: PageDir) {
    this._name = name
    this._parent = parent
    if (parent) {
      parent.add(this)
    }
  }

  get parent() {
    return this._parent
  }

  set parent(parent: PageDir | undefined) {
    if (this._parent === parent) return
    this._parent?.remove(this)
    parent?.add(this)
    this._parent = parent
  }

  get path(): string {
    return posixPath.join(this.parent?.path ?? '/', this.name)
  }

  get routePath(): string {
    return posixPath.join(this.parent?.routePath ?? '/', this.name)
  }

  get name(): string {
    return this._name
  }

  abstract getRoutes(parent?: Route, parentPath?: string): Route[]
}

class PageDir extends PageBase {
  private _files = new Map<string, PageFile>()
  private _dirs = new Map<string, PageDir>()
  private _routePath: string | undefined

  constructor(
    name: string,
    parent?: PageDir | undefined,
    children?: (PageDir | PageFile)[],
    routePath?: string
  ) {
    super(name, parent)
    if (children) {
      for (const child of children) {
        this.add(child)
      }
    }
    this._routePath = routePath
  }

  get routePath(): string {
    return this._routePath ?? super.routePath
  }

  set routePath(path: string | undefined) {
    this._routePath = path
  }

  add(child: PageBase) {
    if (child instanceof PageDir) {
      let oldChild: PageDir | undefined
      if ((oldChild = this._dirs.get(child.name)) === child) return false
      if (oldChild) {
        oldChild._parent = undefined
      }
      this._dirs.set(child.name, child)
      child._parent = this
      return true
    } else if (child instanceof PageFile) {
      const nameObj = child.nameObj
      let oldChild: PageFile | undefined
      if ((oldChild = this._files.get(nameObj.basename)) === child) return false
      if (oldChild) {
        const oldNameChild = oldChild.nameObj
        if (oldNameChild.priority < nameObj.priority) return false
        oldChild._parent = undefined
      }
      this._files.set(nameObj.basename, child)
      child._parent = this
      return true
    }
    return false
  }

  remove(child: PageBase) {
    if (child instanceof PageDir) {
      return this._dirs.delete(child.name)
    } else if (child instanceof PageFile) {
      const nameObj = child.nameObj
      if (this._files.get(nameObj.basename) === child) {
        return this._files.delete(nameObj.basename)
      }
    }
    return false
  }

  removeFile(name: string) {
    for (const [basename, file] of this._files) {
      if (file.name === name) {
        this._files.delete(basename)
        return true
      }
    }
    return false
  }

  removeDir(name: string) {
    return this._dirs.delete(name)
  }

  getRoutes(parent?: Route, parentPath?: string): Route[] {
    let routePath
    if (this._routePath !== null) {
      routePath = this._routePath
    } else if (parentPath != null) {
      routePath = posixPath.join(parentPath, this.name)
    } else {
      routePath = this.routePath
    }
    const routes: Route[] = []
    const intersectionKeys = new Set(
      intersection(Array.from(this._dirs.keys()), Array.from(this._files.keys()))
    )
    for (const [name, dir] of this._dirs) {
      if (intersectionKeys.has(name)) continue
      routes.push(...dir.getRoutes(parent, parentPath))
    }
    for (const [name, file] of this._files) {
      if (intersectionKeys.has(name)) continue
      routes.push(...file.getRoutes(parent, parentPath))
    }
    for (const name of intersectionKeys) {
      const dir = this._dirs.get(name)!

      let [_fileRoute, ..._otherFileRoutes] = this._files.get(name)!.getRoutes(parent, routePath)

      if (dir._routePath != null || !_fileRoute) {
        routes.push(...dir.getRoutes(parent, parentPath))
      } else {
        parent = _fileRoute
        const _dirRoutes = dir.getRoutes(parent, '/')
        _fileRoute.children = _fileRoute.children
          ? _dirRoutes.concat(_fileRoute.children!)
          : _dirRoutes
        _fileRoute.routes = _fileRoute.routes ? _dirRoutes.concat(_fileRoute.routes!) : _dirRoutes
        _fileRoute.exact = true
      }
      routes.push(_fileRoute, ..._otherFileRoutes)
    }

    return routes
  }

  getDir(name: string) {
    return this._dirs.get(name)
  }

  mkdir(name: string) {
    let dir = this._dirs.get(name)!
    if (!dir) {
      dir = new PageDir(name)
      dir._parent = this
      this._dirs.set(name, dir)
    }
    return dir
  }

  getFile(name: string) {
    for (const [basename, file] of this._files) {
      if (file.name === name) {
        return file
      }
    }
  }

  clear() {
    this._dirs.clear()
    this._files.clear()
  }

  isEmpty() {
    return this._dirs.size === 0 && this._files.size === 0
  }
}

abstract class PageFile extends PageBase {
  readonly nameObj: { basename: string; extname: string; priority: number }

  constructor(name: string, parent?: PageDir) {
    super(name, parent)
    this.nameObj = this.resolveFilename(name)
  }

  get routePath(): string {
    return posixPath.join(this.parent?.routePath ?? '/', this.nameObj.basename)
  }

  getRoutes(parent?: Route, parentPath?: string): Route[] {
    const routePath = parentPath
      ? posixPath.join(parentPath, this.nameObj.basename)
      : this.routePath
    const path = this.path
    const key = hashString(path, 'route_').substring(0, 16)
    const customBlock = this.customBlock
    const route = {
      props: true,
      ...this.resolveRoutePath(routePath),
      name: key,
      key,
      component: path,
    }

    return [customBlock ? merge(route, customBlock) : route]
  }

  abstract get customBlock(): Record<string, any> | null | undefined
  abstract resetCustomBlock(): boolean
  abstract resolveFilename(name: string): {
    basename: string
    extname: string
    priority: number
  }
  abstract resolveRoutePath(path: string): {
    path: string
    variables?: string[]
    isMatchAll?: boolean
  }
}

const nameReSource = /([a-zA-Z_][a-zA-Z0-9_]*)/.source
const catchAllRoutePrefixReSource = /\.{3}/.source
const dynamicRoutePrefixReSource = /(\.{3}|\+|-)/.source
const dynamicRouteRE = new RegExp(
  `\\[${dynamicRoutePrefixReSource}?${nameReSource}(?:\\(${nameReSource}\\))?\\]`,
  'g'
)
const catchAllRouteRE = new RegExp(`^/?\\[${catchAllRoutePrefixReSource}${nameReSource}\\]/?$`)
const nuxtDynamicRouteRE = new RegExp(`(?<=^|/)_${nameReSource}(?=/|$)`, 'g')
const nuxtCatchAllRouteRE = new RegExp(`^/?_/?$`)

export const transformDynamicRoute = (path: string) => {
  return path.replace(dynamicRouteRE, (_match, modify, variable) => {
    return ['...', '+'].includes(modify) ? '_' : `_${variable}`
  })
}

const getPageFileClass = (
  pagesDir: ResolvedOptions['pagesDir'][number],
  options: ResolvedOptions
) => {
  return class CustomPageFile extends PageFile {
    private _customBlock?: Record<string, any>
    get customBlock(): Record<string, any> | null | undefined {
      if (options.enableRouteBlock) {
        return (
          this._customBlock ?? (this._customBlock = CustomPageFile.resolveCustomBlock(this.path))
        )
      }
    }
    resetCustomBlock(): boolean {
      if (options.enableRouteBlock) {
        const customBlock = CustomPageFile.resolveCustomBlock(this.path)
        if (!deepEqual(customBlock, this._customBlock)) {
          this._customBlock = customBlock
          return true
        }
      }
      return false
    }
    getRoutes(parent?: Route, parentPath?: string): Route[] {
      const routes = super
        .getRoutes(parent, parentPath)
        .map((route) => pagesDir.extendRoute(route, parent))
      return routes
    }
    resolveFilename(name: string): { basename: string; extname: string; priority: number } {
      const extname = name.match(pagesDir.extensionsRE)?.[1] ?? ''
      let basename
      let priority

      if (extname) {
        basename = posixPath.basename(name, '.' + extname)
        priority = options.extensions.indexOf(basename)
        if (priority < 0) priority = Infinity
      } else {
        basename = name
        priority = 0
      }

      return { basename, extname, priority }
    }
    resolveRoutePath(path: string): {
      path: string
      variables?: string[] | undefined
      isMatchAll?: boolean | undefined
    } {
      const { nuxtStyle } = options
      const variables: string[] = []
      const isMatchAll = nuxtStyle ? nuxtCatchAllRouteRE.test(path) : catchAllRouteRE.test(path)

      if (nuxtStyle) {
        path = path.replace(nuxtDynamicRouteRE, (_match, variable) => {
          variables.push(variable)
          return ':' + variable
        })
      } else {
        path =
          path
            .replace(dynamicRouteRE, (_match, modify, variable, pattern) => {
              if (pattern) {
                pattern = options.replaceDynamicPatterns[pattern] || pattern
              } else if (modify) {
                pattern = '(.*)'
              } else {
                pattern = ''
              }
              let childPath = `:${variable}${pattern}`

              switch (modify) {
                case '+':
                  childPath += '+'
                  break
                case '-':
                  childPath += '?'
                  break
                case '...':
                  childPath += '*'
                  break
              }

              variables.push(variable)
              return childPath
            })
            .replace(/(^|\/)index$/, '') || '/'
      }

      return {
        path,
        variables,
        isMatchAll,
      }
    }
    private static resolveVueCustomBlock(code: string, path: string) {
      const sfc = vueSfcParse(code, {
        pad: 'space',
      }).descriptor
      const block = sfc.customBlocks.find((b) => b.type === 'route')
      const customBlock: Record<string, any> = Object.create(null)
      if (block) {
        const lang = block.lang ?? options.routeBlockLang

        if (lang === 'json' || lang === 'json5') {
          try {
            merge(customBlock, JSON5.parse(block.content))
          } catch (err: any) {
            throw new Error(
              `Invalid JSON5 format of <${block.type}> content in ${path}\n${err.message}`
            )
          }
        } else if (lang === 'yaml' || lang === 'yml') {
          try {
            merge(customBlock, YAML.parse(block.content))
          } catch (err: any) {
            throw new Error(
              `Invalid YAML format of <${block.type}> content in ${path}\n${err.message}`
            )
          }
        }
      }
      const script = sfc.script
      if (script && ['jsx', 'tsx'].includes(script.lang!)) {
        merge(customBlock, CustomPageFile.resolveJsxCustomBlock(script.content, path))
      }
      return customBlock
    }
    private static resolveJsxCustomBlock(code: string, path: string) {
      try {
        const customBlock: Record<string, any> = Object.create(null)
        const ast = babelParse(code, babelParseOptions)
        const constances: Record<string, any> = Object.create(null)
        traverse(ast, {
          VariableDeclaration(path) {
            const { declarations, kind } = path.node
            if (kind === 'const') {
              for (const decl of declarations) {
                const { init, id } = decl
                if (id.type === 'Identifier') {
                  const value = parseConstance(init!, constances)
                  if (value !== UnkownValue) {
                    constances[id.name] = value
                  }
                }
              }
            }
          },
          JSXElement(path) {
            debugger
            const {
              openingElement: { name },
              children,
            } = path.node
            if (name.type === 'JSXIdentifier' && name.name === 'route') {
              for (const child of children) {
                if (child.type === 'JSXText') continue
                else if (child.type === 'JSXExpressionContainer') {
                  const exp = child.expression
                  if (exp.type === 'JSXEmptyExpression') continue
                  const value = parseConstance(exp, constances)
                  if (value && typeof value === 'object') {
                    mergeWith(customBlock, value, (objValue, srcValue) => {
                      return srcValue === UnkownValue ? objValue : srcValue
                    })
                  }
                }
                break
              }
              path.stop()
            } else {
              path.skip()
            }
          },
          Function(path) {
            path.skip()
          },
        })
        return customBlock
      } catch (err) {}
    }
    private static resolveCustomBlock(path: string) {
      const extname = path.match(pagesDir.extensionsRE)?.[1]
      if (!extname) return
      const customBlock: Record<string, any> = Object.create(null)
      const loadCode = once(() =>
        fs.readFileSync(posixPath.join(options.root, normalizePath(path))).toString()
      )
      if (/\.vue$/i.test(path)) {
        merge(customBlock, CustomPageFile.resolveVueCustomBlock(loadCode(), path))
      } else if (/\.[tj]sx$/i.test(path)) {
        merge(customBlock, CustomPageFile.resolveJsxCustomBlock(loadCode(), path))
      }
      return customBlock
    }
  }
}

export class PageFS extends EventEmitter {
  readonly root = new PageDir('/')

  constructor(public readonly options: ResolvedOptions) {
    super()
  }

  normalizePath(path: string) {
    if (posixPath.isAbsolute(path)) return normalizePath(path)
    return '/' + normalizePath(Path.relative(this.options.root, Path.resolve(path)))
  }

  mkpath(path: string, ...args: [children?: (PageDir | PageFile)[], routePath?: string]) {
    path = this.normalizePath(path)
    const paths = path.split('/').filter((child) => child && child !== '.')
    let dir = this.root

    for (const pathChild of paths) {
      dir = dir.mkdir(pathChild)
    }

    if (args.length > 0) {
      const children = args[0]
      if (children) {
        for (const child of children) {
          dir.add(child)
        }
      }
    }

    if (args.length > 1) {
      const routePath = args[1]
      dir.routePath = routePath
    }

    return dir
  }

  getDir(path: string) {
    path = this.normalizePath(path)
    const paths = path.split('/').filter((child) => child && child !== '.')
    let dir = this.root

    for (const pathChild of paths) {
      if (!dir) return
      dir = dir.getDir(pathChild)!
    }
    return dir
  }

  getFile(path: string) {
    path = this.normalizePath(path)
    const dirname = posixPath.dirname(path)
    const name = posixPath.basename(path)
    const dir = this.getDir(dirname)
    if (!dir) return
    return dir.getFile(name)
  }

  addFile(
    path: string,
    FileClass: new (...args: ConstructorParameters<typeof PageFile>) => PageFile
  ) {
    path = this.normalizePath(path)
    const dirname = posixPath.dirname(path)
    const name = posixPath.basename(path)
    const dir = this.mkpath(dirname)
    const file = new FileClass(name)
    return dir.add(file)
  }

  removeFile(path: string) {
    path = this.normalizePath(path)
    const dirname = posixPath.dirname(path)
    const name = posixPath.basename(path)
    const dir = this.getDir(dirname)
    if (!dir) return false
    const result = dir.removeFile(name)
    if (dir.isEmpty()) {
      dir.parent?.remove(dir)
    }
    return result
  }

  async watch() {
    let ready = false

    const root = this.options.root
    const _reload = debounce(() => this.emit('reload'), 50)
    const reload = () => {
      if (ready) {
        _reload()
      }
    }

    const watchers = await Promise.all(
      this.options.pagesDir.map((pageDir) => {
        return new Promise<FSWatcher>((resolve) => {
          const PageFile = getPageFileClass(pageDir, this.options)
          const { dir, baseRoute } = pageDir
          const fsPath = normalizePath('/' + dir)
          const cwd = posixPath.join(root, dir)
          const ext = extensionsToGlob(pageDir.extensions)
          const ignored = pageDir.exclude
          this.mkpath(fsPath, undefined, baseRoute)

          const watcher = watch(`**/*.${ext}`, {
            ignored,
            ignoreInitial: false,
            ignorePermissionErrors: true,
            atomic: true,
            cwd,
          })

          watcher.on('change', (name: any, stats: any) => {
            const filename = posixPath.join(cwd, name)
            this.emit('change', filename, stats)
            if (
              (stats as fs.Stats).isFile() &&
              this.getFile(posixPath.join(fsPath, name))?.resetCustomBlock()
            ) {
              debug.hmr('change', filename)
              reload()
            }
          })

          watcher.on('add', (name: any, stats: any) => {
            const filename = posixPath.join(cwd, name)
            this.emit('add', filename, stats)
            if (
              (stats as fs.Stats).isFile() &&
              this.addFile(posixPath.join(fsPath, name), PageFile)
            ) {
              debug.hmr('add', filename)
              reload()
            }
          })

          watcher.on('unlink', (name: any) => {
            const filename = posixPath.join(cwd, name)
            this.emit('remove', filename)
            if (this.removeFile(posixPath.join(fsPath, name))) {
              debug.hmr('remove', filename)
              reload()
            }
          })

          watcher.on('ready', () => {
            resolve(watcher)
          })
        })
      })
    )

    ready = true

    return {
      stop: () => {
        watchers.forEach((watch) => watch.close())
      },
      getRoutes: () => {
        return this.root.getRoutes()
      },
    }
  }

  on(event: 'change', listener: (filename: string, stat: fs.Stats) => void): this
  on(event: 'add', listener: (filename: string, stat: fs.Stats) => void): this
  on(event: 'remove', listener: (filename: string) => void): this
  on(event: 'reload', listener: () => void): this

  on(event: string | symbol, listener: (...args: any[]) => void) {
    super.on(event, listener)
    return this
  }
}

const babelParseOptions: BabelParserOptions = {
  allowImportExportEverywhere: true,
  attachComment: false,
  plugins: [
    'asyncDoExpressions',
    'asyncGenerators',
    'bigInt',
    'classPrivateMethods',
    'classPrivateProperties',
    'classProperties',
    'classStaticBlock',
    'decimal',
    ['decorators', { decoratorsBeforeExport: true }],
    'doExpressions',
    'dynamicImport',
    'exportDefaultFrom',
    'importMeta',
    'jsx',
    'logicalAssignment',
    'importAssertions',
    'moduleBlocks',
    'moduleStringNames',
    'nullishCoalescingOperator',
    'numericSeparator',
    'objectRestSpread',
    'optionalCatchBinding',
    'optionalChaining',
    'partialApplication',
    'placeholders',
    'throwExpressions',
    'typescript',
  ],
}
